#! /usr/bin/env python
"""
Purpose:
--------
A simple $HOME directory files remover: all it does is listing files
with their estimated size on disk, and making it easy to remove
them. This was meant as a cheap rip-off of Nautilus for the only real
use the author ever had of a graphical file manager.

Files are not really removed; instead, they are moved from their
current position under $HOME to $HOME/.Trash (In a nautilus-like
fashion). In addition, it preserves relative paths so that it
is easy to restore the files as well:

cp -rav $HOME/.trash/* $HOME

Requirements:
-------------
- A minimaly POSIX compliant system, with the $HOME environment variable
  set
- Python >= 2.5 with threading support
- wxPython when used as a standalone script (developped on wxPython
  2.6.3.3): this is not needed to use as a module

Credits:
--------
Copyright (C) 2006, Sylvain Fourmanoit <syfou@users.sourceforge.net>

This code is released under the terms of the GNU General Public
License (GPL), version 2. You should have received a complete copy of
the licence along the software: see COPYING.

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies of the Software and its documentation and
acknowledgment shall be given in the documentation and software
packages that this Software was used.

THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""

#-------------------------------------------------------------------------------
# Imports
#
import os, os.path, select
import threading
import math

#-------------------------------------------------------------------------------
# Set the logging facility
#
import logging

class _FakeStream:
    def write(self, msg): pass
    def flush(self): pass

# We could just use /dev/null, but this allows us to cleanly report
# the error later on non-POSIX systems; see Ctrl.check_environment()
#
logging.basicConfig(level=logging.DEBUG,
                    stream=_FakeStream())

# Set a single console handler for now, that only report warnings
#
# (We keep the _console symbol around, just in case someone wants to change
# console logging parameters later on when using zap.py as a module).
#
console = logging.StreamHandler()
console.setFormatter(logging.Formatter('%(asctime)s %(message)s'))
console.setLevel(logging.WARNING if __name__ == '__main__' else logging.DEBUG)
logging.getLogger('').addHandler(console)

#-------------------------------------------------------------------------------
# Model
#
class Stats(dict):
  """
  Statistics interface. Once initialized, it contains:

  - The current working directory in Stats.cwd (default to $HOME)
  - The files inside the current working directory in self.files
  - The subdirs of the current working directory in self.subdirs
  - The estimated size of the current working directory in
    Stats.cwd_size
  - The stats information as the class itself, indexed by canonical
    filenames (the size of files and estimated size of directories in
    KiloBytes)

  Note 1: The stats information on subdirs are fetched asynchronously:
          use stats.complete(), or shield yourself from key errors
          when accessing them.

  Note 2: when accessing stats, relatives file names are presumed to
          be referring to Stats.cwd.

  If you wish to subclass Stats, you might want to use the hooks
  provided: stats.notify_chdir(), stats.notify_item_change(),
  stats.notify_completion().
  """
  def __init__(self, base='%(HOME)s', ctrl=None):
    dict.__init__(self)
    self._t     = None
    self._tstop = False
    self.cwd    = ''
    self.base   = os.path.abspath(base % os.environ)
    self.ctrl   = ctrl
    self.chdir(self.base)

  def __del__(self): self.stop()

  def notify_chdir(self):
    """Hook for directory change"""
    pass
  
  def notify_item_change(self, key):
    """Hook for asynchronous item change notification"""
    pass

  def notify_completion(self):
    """Hook for complete stats fetching"""

  def fullname(self, filename):
    """
    Get the filename fullname relative to the current working
    directory
    """
    return os.path.join(self.cwd, filename)
  
  def iter_fullnames(self, filenames):
    """
    Apply Stats.fullname on some iterable
    """
    for filename in filenames:
      fn = self.fullname(filename)
      if not fn in self.exclude:
        yield self.fullname(filename)
      
  def __getitem__(self, key):
    return dict.__getitem__(self, self.fullname(key))

  def has_key(self, key):
    return dict.has_key(self, self.fullname(key))

  def chdir(self, cwd, exclude=['%(HOME)s/.Trash']):
    """
    Change directory (and get new stats). See the class description
    for more details.
    
    Warning: part of the operation is asynchronous (determination of
    the subdirs estimated size): you might use Stats.complete() to
    know when this is really over.

    Note: the exlusion list are files that will be completely ignored
    in stats.
    """

    # Make sure to stop any running async operation, then reset the params
    #
    self.stop()
    self.cwd  = os.path.abspath(self.fullname(cwd))
    self.cwd_size = -1

    # Make sure we never escape from self.base
    #
    if not self.base == self.cwd[:len(self.base)]:
      logging.warning('cannot escape "%s" base directory' % self.base)
      self.cwd = self.base
    
    # Expand the exclusion list
    #
    self.exclude = [self.fullname(item % os.environ) for item in exclude]
    
    # Get the basic infomation (filenames)
    #
    ls = [f for f in os.listdir(self.cwd)
          if not self.fullname(f) in self.exclude]
    self.files = [f for f in ls
                  if (not os.path.isdir(self.fullname(f))
                      or os.path.islink(self.fullname(f)))]
    self.dirs  = [f for f in ls if not f in self.files]

    # Sort the subdirs and the files alphabetically
    #
    for l in (self.files, self.dirs): l.sort()

    # Determine size from everything but real subdirs right now...
    #
    for f in self.iter_fullnames(self.files):
      try:
        if not self.has_key(f):
          self[f] = int(math.ceil(os.path.getsize(f) / 1024.))
      except OSError:
	self[f] = 0

    # ...and initiate the asynchronous size determination of those.
    #
    self._t = threading.Thread(target=self._stats)
    self._t.start()

    # Finally, mention we changed directory
    #
    self.notify_chdir()

  def _stats(self):
    """
    Size sub-directories asynchronously using coreutils' disk usage
    program (du)
    """
    # Get the subdirs size
    #
    for f in self.iter_fullnames(self.dirs):
      logging.info('Polling ' + f)
      if not self._tstop:
        if not self.has_key(f):
          dup, fd, dup = os.popen3('du -s "%s"' % f)
          p  = select.poll(); p.register(fd, select.POLLIN)
          while len(p.poll(.1)) == 0:
            if self._tstop is True:
              break
          if not self._tstop:
            self[f] = int(fd.read().split()[0])
            self.notify_item_change(f)
      else:
        break

    # Get the cwd size
    #
    if not self._tstop:
      self.cwd_size = sum([self[f] for f in (self.files + self.dirs)])
      self.notify_completion()
      logging.info('Stats completed')
    else:
      self._tstop = False
      logging.info('Stats cancelled')
            
  def complete(self):
    """Is the operation complete?"""
    return self.cwd_size >= 0
  
  def stop(self):
    """Stop the async operation"""
    if type(self._t) is threading.Thread and self._t.isAlive():
      self._tstop = True
      self._t.join()

  def refresh(self):
    """
    Refresh the directory info (predefined call to chdir)

    Warning: the content of self.dirs and self.files are always
    refreshed, but be aware that size are not recomputed unless
    manually removed from the cache:

    del Stats[/some/complete/filename]

    Or even Stats.clear() to force it on all entries.
    """
    self.chdir(self.cwd, self.exclude)

  def is_stats(self):
    """
    Indicates if current list is stats or trash in the Control class

    Clearly, this method violates pure Model-View-Control (since this
    is a control gateway), but it makes UI manipulation a lot simpler.
    """
    if not self.ctrl is None:
      return (self is self.ctrl.stats)
    else:
      raise RuntimeError('no Control instance for stats')
      
  def zap(self, filenames):
    """
    Zap files from the Stats
    
    (same remarks regarding MVC best practices applies)
    """
    if self.is_stats():
        self.ctrl.zap(filenames)
    else:
        self.ctrl.unzap(filenames)

#-------------------------------------------------------------------------------
# Control
#
class Ctrl:
  """
  Stats control interface: add the required methods to zap and unzap
  files, while automatically refreshing the stats.
  """
  def __init__(self, StatsCls = Stats):
    """Initialize the stats control using the passed stats class"""
    self.check_environment()
    self.stats = StatsCls('%(HOME)s', ctrl=self)
    self.trash = StatsCls('%(HOME)s/.Trash', ctrl=self)

  # Ctrl.__del__ is somewhat redundant (see Stats.__del__), but we
  # keep it around just to ease external reuse: it doesn't hurt
  # either.
  #
  def __del__(self): self.stop()
  
  def stop(self):
    """Stop asynchronous stats fetching"""
    for s in ('stats', 'trash'):
      if hasattr(self, s):
        getattr(self, s).stop()

  def check_environment(self):
    """
    High-level validity check... Check various conditions
    needed by the script to run

    This is automatically invoked from the initializer.
    """
    # Is $HOME set?
    #
    if not os.environ.has_key('HOME'):
      raise RuntimeError('no $HOME environment variable set')

    # Is /dev/null available?
    #
    if not os.path.exists('/dev/null'):
      raise RuntimeError('no /dev/null device available')
    
    # Is $HOME/.Trash usable?
    #
    trash = '%(HOME)s/.Trash' % os.environ
    if not os.path.exists(trash):
      try:
        os.mkdir(trash)
      except OSError, e:
        raise RuntimeError(str(e))
    elif not os.path.isdir(trash):
      raise RuntimeError(
        'Trash path "%s" exists, but is not a directory' % trash)

    # Does the system have du ?
    #
    if os.system('du /dev/null > /dev/null 2>&1') != 0:
      raise RuntimeError('Disk usage utility (du) could not be executed')

  def zap(self, filenames):
    """
    Zap given file list: move it from somewhere under $HOME to the
    equivalent place under $HOME/.Trash
    """
    self._zap_unzap(filenames, self.stats, self.trash, '"%s" zapped')
    
  def unzap(self, filenames):
    """
    Unzap given file list: move it from somewhere under $HOME/.Trash to
    the equivalent place under $HOME
    """
    self._zap_unzap(filenames, self.trash, self.stats, '"%s" restored')

  def _zap_unzap(self, filenames, sstats, dstats, msg):
    sstats.stop(); dstats.stop()
    try:
      # Determine source and destination
      #
      for filename in filenames:
        src = sstats.fullname(filename)
        dst = os.path.join(dstats.base,
                           src[len(os.path.commonprefix([sstats.base, src]))+1:])
        
        # Let's keep things simple: we just operate if the file we want
        # to create does not exist at destination
        #
        if not os.path.exists(dst):
          # Make sure all the parent directory exist though
          #
          if not os.path.exists(os.path.dirname(dst)):
            os.makedirs(os.path.dirname(dst))
        
          # Finally move the file
          #
          os.rename(src, dst)
        else:
          raise RuntimeError('destination file "%s" already exists' % dst)

        # Get rid of those keys/values pair for completeness
        #
        if sstats.has_key(src):
          dstats[dst] = sstats[src]
          del sstats[src]

        # And log the successful operation
        #
        logging.info(msg % filename)
        
    # The stats always get refreshed anyway
    #
    except:
      raise
    finally:
      sstats.refresh(); dstats.refresh()

#-------------------------------------------------------------------------------
# View
#
try:
  # Using zap.py as a module? Don't import the interface
  #
  if __name__ != '__main__':
    raise ImportError('no need for the interface')

  import wx, cStringIO
  import wx.lib.mixins.listctrl as listmix
  
  try:
    import wx.html, wx.lib.wxpTag	# Just used for help
  except:
    _html_support = False
  else:
    _html_support = True

  class _App(wx.App):
    
    suspend = False	# Suspend state: used to quiet exception on exit
    
    class ProxyStats(Stats):
      """Statistics proxy for easy data integration with wxPython"""
      class ItemDataMap(dict):
        def __init__(self, stats):
          dict.__init__(self)
          self._stats = stats
          self.refresh()
      
        def refresh(self):
          """Fully rebuild the data map"""
          self.clear()
          self.update(enumerate(
            [(f, self._stats[f] if self._stats.has_key(f) else 0, 'Dir')
             for f in self._stats.dirs] +
            [(f, self._stats[f], 'File') for f in self._stats.files]))
      
          # Revert map is used to speed-up the item change mapping
          #
          self._revertmap = dict(
            [(self._stats.fullname(f), i)
             for i, (f, s, t) in self.iteritems()])

        def notify_item_change(self, key):
          """Rebuild a single value based on notification"""
          rkey       = self._revertmap[key]
          f, s, t    = self[rkey]
          self[rkey] = (f, self._stats[key], t)
              
        def itemtext(self, key):
          if not type(key) is int:
            key = self._revertmap[key]
          f, s, t = self[key]
          return f, self.sizestr(s, t == 'Dir'), t
      
        def iteritemstext(self):
          """
          Specialized iterator used to populate original
          representation
          """
          for i in self.iterkeys():
            yield i, self.itemtext(i)
    
        def sizestr(self, size, isdir = False):
          """
          Express an integer size in KyloBytes as a human-readable
          string
          """
          if size > 1048576:
            return '%.2f GB' % (size/1048576.)
          elif size > 1024:
            return '%.2f MB' % (size/1024.)
          elif size == 0 and isdir:
            return 'Unknown'
          else:
            return '%s KB' % size

      class ItemIndexMap(list):
        def __init__(self, stats):
          list.__init__(self)
          self._stats = stats
          self.refresh()

        def refresh(self):
          """
          Yes, this is a __poor__ implementation: all we want is to
          avoid reinstantiating a new list, thus loosing the reference
          to this class.
          """
          while len(self) > 0: self.pop()	# Yuck
          self.extend(self._stats.itemDataMap.iterkeys())

      def __init__(self, base='%(HOME)s', ctrl=None):
        Stats.__init__(self, base, ctrl)
        self._list        = None
        self.itemDataMap  = self.ItemDataMap(self)
        self.itemIndexMap = self.ItemIndexMap(self)

        # Setup post-initialization hooks
        #
        for method in ('notify_item_change',
                       'notify_completion',
                       'notify_chdir'):
          self.__dict__[method] = getattr(self, '_' + method)

      def _notify_item_change(self, key): 
        """Update the low-level data map and representation"""
        self.itemDataMap.notify_item_change(key)
        self._refresh_list()

      def _notify_completion(self):
        """Make sure the high-level display is up to date"""
        self._refresh_list()

      def _notify_chdir(self):
        """
        Make sure the itemDataMap and itemIndexMap are up to date
        """
        self.itemDataMap.refresh()
        self.itemIndexMap.refresh()

      def _refresh_list(self):
        if not self._list is None:
          self._list.refresh_flag.set()

    class MainWindow(wx.Frame):
      def __init__(self):
        wx.Frame.__init__(self, None, -1, 'Zap')
        self.Show()

    class FileListCtrl(wx.ListCtrl, listmix.ListCtrlAutoWidthMixin):
      """
      Implementation as a 'virtual' ListCtrl, following ASPN recipe:

      http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/426407

      What is great with this implementation is that you don't have to
      care about manually updating individual elements: the changes in
      the underlying ProxyStats structures are cleanly repercuted.
      """
      def __init__(self, parent, stats):
        wx.ListCtrl.__init__(self, parent,
                             wx.NewId(),
                             wx.DefaultPosition,
                             wx.DefaultSize,
                             wx.LC_REPORT | wx.LC_VIRTUAL | wx.BORDER_NONE)
        listmix.ListCtrlAutoWidthMixin.__init__(self)

        # Set a thread-safe refresh flag, checked on idle time by the
        # UI thread
        #
        self.refresh_flag = threading.Event()
        self.Bind(wx.EVT_IDLE, self.OnIdle)
        
        # Set the captions and sizing policy
        #
        for i, caption in enumerate(('Filename', 'Size', 'Type')):
          self.InsertColumn(i, caption)
          self.SetColumnWidth(i, wx.LIST_AUTOSIZE)

        # And keep tracks of the underlying stats
        #
        self._stats = stats
        self._stats._list = self
        self.SetItemCount(len(self._stats.itemIndexMap))
      
      def OnIdle(self, event):
        if self.refresh_flag.isSet():
          self.Refresh()
          self.refresh_flag.clear()

      # Virtualization routines
      #
      def OnGetItemText(self, item, col):
        index = self._stats.itemIndexMap[item]
        s = self._stats.itemDataMap.itemtext(index)[col]
        return s
      
      def SortItems(self, sorter=cmp):
        # It's possible not to refetch keys from itemIndexMap because
        # we know it never changes between invokation of Stats.chdir()
        #
        self._stats.itemIndexMap.sort(sorter) 
        self.Refresh()

      def Refresh(self):
        self.SetItemCount(len(self._stats.itemIndexMap))
        wx.ListCtrl.Refresh(self)

      def SelectionClear(self):
        index = self.GetFirstSelected()
        while index != -1:
          self.Select(index, 0)
          index = self.GetNextSelected(index)

    class FileListCtrlPanel(wx.Panel, listmix.ColumnSorterMixin):
      def __init__(self, parent, stats, sb):
        wx.Panel.__init__(self, parent, -1, style=wx.WANTS_CHARS)

        self.stats = stats
        self.sb    = sb
        self.il    = wx.ImageList(16, 16)
        self.sm_up = self.il.Add(self.getArrowBitmap(0))
        self.sm_dn = self.il.Add(self.getArrowBitmap(1))
        self.list  = _App.FileListCtrl(self, stats)
        self.list.SetImageList(self.il, wx.IMAGE_LIST_SMALL)

        # Now we can set the itemDataMap dictionary, initialize
        # the other base class.
        #
        self.itemDataMap = self.stats.itemDataMap
        listmix.ColumnSorterMixin.__init__(self, 3)

        # Hook events
        #
        self.Bind(wx.EVT_SIZE, self.OnSize)
        self.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.OnItemActivated, self.list)
        self.list.Bind(wx.EVT_RIGHT_DOWN, self.OnRightDown)
        self.list.Bind(wx.EVT_KEY_DOWN, self.OnKeyDown)        
        
      # Used by the ColumnSorterMixin, see wx/lib/mixins/listctrl.py
      def GetListCtrl(self):
        return self.list

      # Used by the ColumnSorterMixin, see wx/lib/mixins/listctrl.py
      def GetSortImages(self):
        return (self.sm_dn, self.sm_up)

      def OnKeyDown(self, event):
        """The keyboard shortcuts handler"""
        key = event.GetKeyCode()
        if key == 8 or key == 32:
          self.OnChParentDir(None)
        elif key in (ord('D'), ord('Z'), ord('U')):
          self.OnZap(None)
        elif key == ord('R'):
          self.OnRefresh(None)
        else:
          event.Skip()	# Not done: keep propagating
        
      def OnRightDown(self, event):
        # Create, then populate the contextual menu
        #
        menu = wx.Menu()
        for caption, func in [
          ('Change Directory...', self.OnChParentDir),
          (('Unzap', 'Zap')[self.stats.is_stats()] + ' Selection', self.OnZap),
          ('Refresh', self.OnRefresh)]:
          ID = wx.NewId()
          self.Bind(wx.EVT_MENU, func, id=ID)
          menu.Append(ID, caption)

        # Popup the menu
        #
        self.PopupMenu(menu)
        menu.Destroy()

      def OnChParentDir(self, event):
        self.chdir('..')

      def OnZap(self, event):
        # Retrieve the filenames
        #
        filenames = []
        index = self.list.GetFirstSelected()
        while index != -1:
          filenames.append(self.list.GetItemText(index))
          index = self.list.GetNextSelected(index)

        # Then zap them
        #
        if len(filenames) > 0:
          try:
            self.stats.zap(filenames)
          except RuntimeError, e:
            _App.ErrorBox(str(e), 'Could not ' +
                          ('unzap', 'zap')[self.stats.is_stats()])
          self.list.SelectionClear()
          self.list.Refresh()
        else:
          _App.ErrorBox('No file selected')

      def OnRefresh(self, event):
        self.chdir(self.stats.cwd)
        
      def OnSize(self, event):
        w,h = self.GetClientSizeTuple()
        self.list.SetDimensions(0, 0, w, h)

      def OnItemActivated(self, event):
        current = self.list.GetItemText(event.m_itemIndex)
        if current in self.stats.dirs:
          self.chdir(current)

      def chdir(self, cwd):
        self.list.SelectionClear()
        self.stats.chdir(cwd)
        logging.info('Chdir %s' % self.stats.cwd)
        self.list.Refresh()
        self.sb.SetStatusText(self.stats.cwd, 0)
        
      def getArrowBitmap(self, index):
        # Borrowed from Image.py from wxPython 2.6.3.3 demo
        im = ( \
'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x10\x00\x00\x00\x10\x08\x06\
\x00\x00\x00\x1f\xf3\xffa\x00\x00\x00\x04sBIT\x08\x08\x08\x08|\x08d\x88\x00\
\x00\x00<IDAT8\x8dcddbf\xa0\x040Q\xa4{h\x18\xf0\xff\xdf\xdf\xffd\x1b\x00\xd3\
\x8c\xcf\x10\x9c\x06\xa0k\xc2e\x08m\xc2\x00\x97m\xd8\xc41\x0c \x14h\xe8\xf2\
\x8c\xa3)q\x10\x18\x00\x00R\xd8#\xec\xb2\xcd\xc1Y\x00\x00\x00\x00IEND\xaeB`\
\x82',
"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x10\x00\x00\x00\x10\x08\x06\
\x00\x00\x00\x1f\xf3\xffa\x00\x00\x00\x04sBIT\x08\x08\x08\x08|\x08d\x88\x00\
\x00\x00HIDAT8\x8dcddbf\xa0\x040Q\xa4{\xd4\x00\x06\x06\x06\x06\x06\x16t\x81\
\xff\xff\xfe\xfe'\xa4\x89\x91\x89\x99\x11\xa7\x0b\x90%\ti\xc6j\x00>C\xb0\x89\
\xd3.\x10\xd1m\xc3\xe5*\xbc.\x80i\xc2\x17.\x8c\xa3y\x81\x01\x00\xa1\x0e\x04e\
?\x84B\xef\x00\x00\x00\x00IEND\xaeB`\x82" )
        return wx.BitmapFromImage(
          wx.ImageFromStream(cStringIO.StringIO(im[index])))

    class LoggingStatusBar(wx.StatusBar):
      """
      Status bar tailored to display logging outputs on second cell
      """
      class Log:
        def __init__(self, bar):
          self._bar         = bar
          self.msg          = ''

        def write(self, msg):
          self.msg += msg
          
        def flush(self):
	  try:
            self._bar.text = self.msg.strip()
            self._bar.refresh_flag.set()
          except wx._core.PyDeadObjectError:
            pass # Might be raised on window destruction: better raise
	         # it here than somewhere upstream.
          finally:
	    self.msg = ''

      def SetStatusText(self, text, field = 0):
        # The info field is used for default left-side message: when
        # no message is displayed (likely due to previous call to menu
        # highlighting), the last value of self.info is set in place
        # instead (see LoggingStatusBar.Refresh).
        #
        if field == 0:
          self.info = text
        wx.StatusBar.SetStatusText(self, text, field)
        
      def Refresh(self):
        if self.refresh_flag.isSet():
          self.SetStatusText(self.text, 1)
          self.refresh_flag.clear()
        if len(self.GetStatusText(0)) == 0:
          self.SetStatusText(self.info, 0)
        
      def __init__(self, parent):
        wx.StatusBar.__init__(self, parent, -1)
        self.info = ''
        
        self.SetFieldsCount(2)
        self.SetStatusWidths([-1, -1])
        
        self.refresh_flag = threading.Event()
        self._hdlr = logging.StreamHandler(self.Log(self))
        self._hdlr.setLevel(logging.INFO)
        logging.getLogger('').addHandler(self._hdlr)

        self.timer = wx.PyTimer(self.Refresh)
        self.timer.Start(100)

    class ErrorBox(wx.MessageDialog):
      def __init__(self, msg, title='Error', parent = None):
        wx.MessageDialog.__init__(self, parent, msg, title,
                                  wx.OK | wx.ICON_EXCLAMATION)
        self.ShowModal()
        self.Destroy()

    class HTMLBox(wx.Dialog):
      """
      A simple HTML dialog box.
      
      This code is adapted from demo/About.py in wxPython 2.6.3.3
      """
      buttons = {
        'EXIT' : '''
        <wxp module="wx" class="Button">
        <param name="label" value="OK">
        <param name="id"    value="ID_OK">
        </wxp>'''
        }
      
      def __init__(self, parent, title, text):
        wx.Dialog.__init__(self, parent, -1, title,)
        html = wx.html.HtmlWindow(self, -1, size=(420, -1))
        if "gtk2" in wx.PlatformInfo:
          html.SetStandardFonts()
        html.SetPage('<html><body>%s</body></html>' % (text % self.buttons))
        btn = html.FindWindowById(wx.ID_OK)
        ir = html.GetInternalRepresentation()
        html.SetSize((ir.GetWidth() + 25,ir.GetHeight() + 25))
        self.SetClientSize(html.GetSize())
        self.CentreOnParent(wx.BOTH)
        self.ShowModal()
        self.Destroy()

    def OnInit(self):
      # Initiate the stats control class
      #
      try:
        self.ctrl        = Ctrl(self.ProxyStats)
      except RuntimeError, e:
        logging.error(str(e))
        return False
      
      # Set the main window and status bar
      #
      self.win         = self.MainWindow()
      self.sb          = self.LoggingStatusBar(self.win) #, self.stats.cwd)
      self.win.SetStatusBar(self.sb)

      # Set the menu bar...
      #
      mb              = wx.MenuBar()
      menu1           = wx.Menu()			# ... File menu
      menu1.Append(101, '&Quit', 'Exit zap.py')
      mb.Append(menu1, '&File')
      self.win.Bind(wx.EVT_MENU, self.OnClose, id=101)
      
      if _html_support:
        menu2           = wx.Menu()			# ... Help menu
        menu2.Append(201, '&Usage', 'Quick help')
        menu2.Append(202, '&About', 'Info on zap.py')  
        mb.Append(menu2, '&Help')
        self.win.Bind(wx.EVT_MENU, self.OnHelp, id=201)
        self.win.Bind(wx.EVT_MENU, self.OnAbout, id=202)
      
      self.win.SetMenuBar(mb)				# Hook the whole thing
      
      # Set the rest
      #
      self.nb          = wx.Notebook(self.win, -1)
      self.panels      = [self.FileListCtrlPanel(
                            self.nb, getattr(self.ctrl, stats), self.sb)
                          for stats in ('stats', 'trash')]
      self.nb.AddPage(self.panels[0], 'Stats')
      self.nb.AddPage(self.panels[1], 'Trash')
      self.sb.info = self.ctrl.stats.cwd
      
      # Hook in the remaining events
      #
      self.Bind(wx.EVT_NOTEBOOK_PAGE_CHANGED, self.OnPageChanged)
      self.Bind(wx.EVT_KEY_DOWN, self.OnKeyDown)
      self.win.Bind(wx.EVT_CLOSE, self.OnUnexpectedClose)
      return True

    def OnKeyDown(self, event):
      if event.GetKeyCode() == ord('Q'):
        self.OnClose(None)
      
    def OnPageChanged(self, event):
      """
      Add a small reminder of the current working directory in the
      status bar.
      """
      self.sb.SetStatusText(self.panels[event.GetSelection()].stats.cwd, 0)
      event.Skip()
        
    def OnAbout(self, event):
      self.HTMLBox(self.win,
                   'About zap.py',
                   '''
<center>
<p>
   <b>zap.py</b> -- a $HOME directory cleanser
<p>
   Copyright &copy; 2006, Sylvain Fourmanoit &lt;syfou@users.sourceforge.net&gt;
</p>
<p>
   Released under the GNU General Public Licence (GPL) version 2
</p>
<p>
   %(EXIT)s
</p>
</center>
''')

    def OnHelp(self, event):
      self.HTMLBox(self.win,
                   'Quick Help',
                   '''
<h1>How to use zap.py</h1>
<ul>
  <li>
  Select one or many files from the <i>Stats</i> tab, and zap them using the
  context menu (or by pressing <b>d</b>, <b>z</b> or <b>u</b>).
  </li>
  <li>
  Enter sub-directories by double-clicking on them. Use the context menu
  (or press <b>backspace</b> or <b>space</b>) to go back to their parents.
  </li>
  <li>
  Same actions can be taken from the <i>Trash</i> tab, but files are restored
  instead; zap.py action is completely non-destructive: files are just moved
  back and forth between <i>$HOME</i> and <i>$HOME/.Trash</i>.
  </li>
  <li>
  Refresh a list by using the context menu (or hit <b>r</b>). No refresh
  will occur automatically.
  </li>
  <li>
  When you are done, quit zap.py (you can press <b>q</b> at all times). If you
  feel safe about your choices, you can clear the Trash (rm -rf $HOME/.Trash),
  or continue the operation where you left it by launching a new zap.py instance
  later. Conversely, you could as well fully restore the original files
  (cp -rav $HOME/.Trash/* $HOME).
  </li>
</ul>
<center><p>%(EXIT)s</p></center>''')
      
    def OnClose(self, event):
      # Called manually: force window destruction
      self.win.Close()

    def OnUnexpectedClose(self, event):
      # Called automatically on EVT_CLOSE
      self.ctrl.stop()
      event.Skip()
      
# zap.py reuse as a module is possible without the interface, so let's
# differ raising the ImportError exception
#
except ImportError, _error: pass
else: _error = None

#-------------------------------------------------------------------------------
# Standalone script initialization
#
if __name__ == '__main__':
  # As a standalone script: start the wxWidgets interface
  #
  if _error is None:
      _App(redirect=False).MainLoop()
  else:
    logging.error('Install a compatible version of wxPython. %s' % str(_error))
else:
  del _error	# No need to keep it in the module namespace: it's irrelevant.

#-------------------------------------------------------------------------------
# That's all, folks!
#
